Я - аналитик сервиса “Ненужные вещи”. Менеджер проекта занимается его продвижением, ему интересно провести ряд экспериментов. Для этого ему необходимо понять какая аудитория у сервиса, c этим запросом он обратился ко мне
Необходимо провести анализ поведения пользователей в мобильном приложении для последующего управления вовлеченностью и получении ряда гипотез
Основные вопросы:
В рамках исследования стоят следующие задачи:
В рамках проекта мы будем использовать два датасета.
Колонки в mobile_sources.csv:
userId — идентификатор пользователя,source — источник, с которого пользователь установил приложение.Колонки в mobile_dataset.csv:
event.time — время совершения,user.id — идентификатор пользователя,event.name — действие пользователя.Виды действий:
advert_open — открыл карточки объявления,photos_show — просмотрел фотографий в объявлении,tips_show — увидел рекомендованные объявления,tips_click — кликнул по рекомендованному объявлению,contacts_show и show_contacts — посмотрел номер телефона,contacts_call — позвонил по номеру из объявления,map — открыл карту объявлений,search_1—search_7 — разные действия, связанные с поиском по сайту,favorites_add — добавил объявление в избранное.#импортируем необходимые библиотеки
!pip install requests
import requests
import math as mth
import numpy as np
import pandas as pd
import seaborn as sns
import plotly.express as px
from scipy import stats as st
from bs4 import BeautifulSoup
import plotly.graph_objs as go
from tqdm.notebook import tqdm
import matplotlib.pyplot as plt
from datetime import datetime, timedelta
import warnings
warnings.filterwarnings("ignore")
Requirement already satisfied: requests in c:\users\belle\anaconda3\lib\site-packages (2.27.1) Requirement already satisfied: idna<4,>=2.5 in c:\users\belle\anaconda3\lib\site-packages (from requests) (3.3) Requirement already satisfied: charset-normalizer~=2.0.0 in c:\users\belle\anaconda3\lib\site-packages (from requests) (2.0.4) Requirement already satisfied: urllib3<1.27,>=1.21.1 in c:\users\belle\anaconda3\lib\site-packages (from requests) (1.26.9) Requirement already satisfied: certifi>=2017.4.17 in c:\users\belle\anaconda3\lib\site-packages (from requests) (2021.10.8)
sources = pd.read_csv('https://code.s3.yandex.net/datasets/mobile_sources.csv')
session = pd.read_csv('https://code.s3.yandex.net/datasets/mobile_dataset.csv')
session¶#приведем названия столбцов к змеиному регистру
session.columns = session.columns.str.replace('.', '_')
session.head(5)
| event_time | event_name | user_id | |
|---|---|---|---|
| 0 | 2019-10-07 00:00:00.431357 | advert_open | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 1 | 2019-10-07 00:00:01.236320 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 2 | 2019-10-07 00:00:02.245341 | tips_show | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
| 3 | 2019-10-07 00:00:07.039334 | tips_show | 020292ab-89bc-4156-9acf-68bc2783f894 |
| 4 | 2019-10-07 00:00:56.319813 | advert_open | cf7eda61-9349-469f-ac27-e5b6f5ec475c |
session.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 74197 entries, 0 to 74196 Data columns (total 3 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 event_time 74197 non-null object 1 event_name 74197 non-null object 2 user_id 74197 non-null object dtypes: object(3) memory usage: 1.7+ MB
В session 74197 строк и 3 столбца:
event_time - время совершения действияevent_name - действие пользователяuser_id - идентификатор пользователяВ датасете нет пропусков
Все столбцы имеют тип object
#изменим тип столбца, где отражается время
session['event_time'] = pd.to_datetime(session['event_time'])
print(session.duplicated().sum()) #проверим наличие дубликатов
print(session['event_name'].unique()) #проверим столбец на наличие неявных дубликатов
0 ['advert_open' 'tips_show' 'map' 'contacts_show' 'search_4' 'search_5' 'tips_click' 'photos_show' 'search_1' 'search_2' 'search_3' 'favorites_add' 'contacts_call' 'search_6' 'search_7' 'show_contacts']
В датасете дубликатов нет.
В столбце sources неявных дубликатов нет, но есть два одинаковых дейтсвия - contacts_show и show_contacts. Объеденим их в одно.
session['event_name'] = session['event_name'].replace('contacts_show', 'show_contacts')
sources¶#приведем названия столбцов к змеиному регистру
sources = sources.rename(columns={'userId':'user_id'})
sources.head(5)
| user_id | source | |
|---|---|---|
| 0 | 020292ab-89bc-4156-9acf-68bc2783f894 | other |
| 1 | cf7eda61-9349-469f-ac27-e5b6f5ec475c | yandex |
| 2 | 8c356c42-3ba9-4cb6-80b8-3f868d0192c3 | yandex |
| 3 | d9b06b47-0f36-419b-bbb0-3533e582a6cb | other |
| 4 | f32e1e2a-3027-4693-b793-b7b3ff274439 |
sources.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 4293 entries, 0 to 4292 Data columns (total 2 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 user_id 4293 non-null object 1 source 4293 non-null object dtypes: object(2) memory usage: 67.2+ KB
В sources 4293 строк и 2 столбца:
user_id - время совершения действияsource - действие пользователяВ датасете нет пропусков
Все столбцы имеют тип object
print(session.duplicated().sum()) #проверим на наличие дубликатоd
print(sources['source'].unique()) #проверим столбец на наличие неявных дубликатов
0 ['other' 'yandex' 'google']
В датасете дубликатов нет, в столбце sources неявных дубликатов тоже нет
Установим значение в 20 минут между двумя соседними действиями, это и будет тайм-аутом сессии
#отсортируем датасет по дате и уникальным пользователям
session = session.sort_values(['user_id', 'event_time'])
# определим нумерацию сессий, значение будет меняться если время между действиями отличается больше, чем на 20 минут
g = (session.groupby('user_id')['event_time'].diff() > pd.Timedelta('20Min')).cumsum()
#присвоим эту нумерацию в столбец `session_id`
session['session_id'] = session.groupby(['user_id', g], sort=False).ngroup() + 1
session.tail(2)
| event_time | event_name | user_id | session_id | |
|---|---|---|---|---|
| 72688 | 2019-11-03 16:08:18.202734 | tips_show | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 10975 |
| 72689 | 2019-11-03 16:08:25.388712 | tips_show | fffb9e79-b927-4dbb-9b48-7fd09b23a62b | 10975 |
В итоге вышло 10975 сессий
Вывод.
В рамках проекта будем работать с двумя датасетами - session и sources
session
sources
Для дальнейшей работы выделили и добавили столбец с номерами сессий, разницу взяли в 20 минут
Проведем анализ предоставленных даных. Для начала посмотрим данные за какой период у нас есть
session.head(3)
| event_time | event_name | user_id | session_id | |
|---|---|---|---|---|
| 805 | 2019-10-07 13:39:45.989359 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 1 |
| 806 | 2019-10-07 13:40:31.052909 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 1 |
| 809 | 2019-10-07 13:41:05.722489 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 1 |
print(f'В таблице данные с {session["event_time"].min()} по {session["event_time"].max()}')
В таблице данные с 2019-10-07 00:00:00.431357 по 2019-11-03 23:58:12.532487
#построим гистограмму по дате и времени
plt.figure(figsize=(15,5))
session['event_time'].hist(bins=1000, color='#668567')
plt.title('Время активности пользователей в приложении')
plt.xlabel('Дата и время')
plt.ylabel('Частота')
plt.show()
Данные предоставлены за период с 9 октября по 5 ноября. Распределены они примерно одинаково, видно, что пики и спады происходят с похожим интервалом.
Приблизим график чтобы посмотреть время активности пользователей. Построим для сравнения два графика по случано выбранными датами
plt.figure(figsize=(15,12))
plt.subplot(2,1,1)
session['event_time'].hist(bins=1000, color='#668567')
plt.xlim(pd.datetime(2019,10,13), pd.datetime(2019,10,15))
plt.title('Время активности пользователей в приложении с 13 по 15 октября 2019 года')
plt.xlabel('Дата и время')
plt.ylabel('Частота')
plt.subplot(2,1,2)
session['event_time'].hist(bins=1000, color='#d9e9a9')
plt.xlim(pd.datetime(2019,10,25), pd.datetime(2019,10,27))
plt.title('Время активности пользователей в приложении с 25 по 27 октября 2019 года')
plt.xlabel('Дата и время')
plt.ylabel('Частота')
plt.show()
Графики схожи между собой. Пользователи проявляли меньше всего активности в ночное время - с 24.00 до 8.00.
Конкретного пика активности днем выделить тяжело. Пользователи с разной частотой заходят в приложение с 12 до 24.00. Такое поведение было ожидаемо, аномальных значений нет.
Необходимо узнать как долго пользователи взаимодействовали с приложением, для этого построим таблицу удержания.
#подготовим таблицу для рассчета
retention = session.copy()
#построим сводную таблицу, где найдем время первого посещения
x = retention.pivot_table(index='user_id', values='event_time', aggfunc='first').reset_index()
#переименуем колонки
x.columns=['user_id', 'first']
#объеденим две таблицы
retention = retention.merge(x, on='user_id')
#выделим столбец с датой
retention['dt'] = retention['first'].dt.date
#оставим только одно действие в рамках сессии
retention =retention.loc[~retention['session_id'].duplicated()]
Напишем фукнцию, которая строит таблицу удержания
def retention_rate(df, horizon):
'''
функция строит таблицу удержания
input: датасет, значение горизонта анализа
output: таблица удержания пользователей
'''
# вычисляем лайфтайм для каждой сессии в днях
df['lifetime'] = (df['event_time'] - df['first']).dt.days
# строим таблицу удержания
result_grouped = df.pivot_table(
index=['dt'],
columns='lifetime',
values='user_id',
aggfunc='nunique')
# вычисляем размеры когорт
cohort_sizes = (df.groupby('dt').agg({'user_id': 'nunique'})
.rename(columns={'user_id': 'cohort_size'}))
# объединяем размеры когорт и таблицу удержания
result_grouped = cohort_sizes.merge(result_grouped, on='dt', how='left').fillna(0)
# делим данные таблицы удержания на размеры когорт
result_grouped = result_grouped.div(result_grouped['cohort_size'], axis=0)
# исключаем из результата все лайфтаймы, превышающие горизонт анализа
result_grouped = result_grouped[['cohort_size'] + list(range(horizon))]
result_grouped['cohort_size'] = cohort_sizes
return result_grouped
#применим фукнцию
rent_r = retention_rate(retention, 15)
rent_r.head()
| cohort_size | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| dt | ||||||||||||||||
| 2019-10-07 | 204 | 1.0 | 0.117647 | 0.093137 | 0.107843 | 0.053922 | 0.034314 | 0.058824 | 0.078431 | 0.053922 | 0.049020 | 0.049020 | 0.034314 | 0.024510 | 0.058824 | 0.049020 |
| 2019-10-08 | 167 | 1.0 | 0.161677 | 0.125749 | 0.053892 | 0.041916 | 0.065868 | 0.053892 | 0.071856 | 0.053892 | 0.035928 | 0.059880 | 0.023952 | 0.029940 | 0.029940 | 0.035928 |
| 2019-10-09 | 176 | 1.0 | 0.073864 | 0.062500 | 0.056818 | 0.068182 | 0.068182 | 0.056818 | 0.073864 | 0.056818 | 0.034091 | 0.022727 | 0.028409 | 0.034091 | 0.056818 | 0.028409 |
| 2019-10-10 | 174 | 1.0 | 0.086207 | 0.103448 | 0.080460 | 0.091954 | 0.080460 | 0.068966 | 0.051724 | 0.080460 | 0.034483 | 0.034483 | 0.051724 | 0.045977 | 0.040230 | 0.034483 |
| 2019-10-11 | 136 | 1.0 | 0.088235 | 0.117647 | 0.095588 | 0.088235 | 0.073529 | 0.066176 | 0.044118 | 0.014706 | 0.044118 | 0.036765 | 0.036765 | 0.051471 | 0.029412 | 0.044118 |
#построим тепловую карту по удержанию
plt.figure(figsize=(15,5))
sns.heatmap(rent_r.drop('cohort_size', axis=1))
plt.title('Тепловая карта удержания пользователей')
plt.xlabel('Лайфтайм')
plt.ylabel('День первого посещения')
plt.show()
Тепловая карта читается плохо, но хорошо видно, что маленький процент людей вовзращается в приложение
print('Доля пользователей на следующий день')
rent_r[1].describe()
Доля пользователей на следующий день
count 28.000000 mean 0.105909 std 0.032922 min 0.000000 25% 0.088385 50% 0.100929 75% 0.128095 max 0.170370 Name: 1, dtype: float64
print('Доля пользователей через неделю')
rent_r[7].describe()
Доля пользователей через неделю
count 28.000000 mean 0.045373 std 0.031044 min 0.000000 25% 0.018519 50% 0.049511 75% 0.070512 max 0.093168 Name: 7, dtype: float64
На следующий день в приложение заходит около 10% пользователей, на 7 день остается около 5%. Показатели небольшие, пользователи редко заходят в приложение повторно
print(session['user_id'].nunique(), 'уникальных пользователей')
4293 уникальных пользователей
#количество сессий у каждого пользователя
num_of_visits = session.pivot_table(index='user_id', values='session_id', aggfunc='nunique')
plt.figure(figsize=(15,5))
plt.subplot(1,2,1)
num_of_visits.boxplot('session_id')
plt.title('Количество сессий у пользователей (полный масштаб)')
plt.ylabel('Количество сессий')
plt.subplot(1,2,2)
num_of_visits.boxplot('session_id')
plt.ylim(0,8)
plt.title('Количество сессий у пользователей(приближенный график)')
plt.ylabel('Количество сессий')
plt.show()
Всего в датасете 4293 уникальных пользователя
За период с 9 октября по 5 ноября 75% пользователей совершили до 6 сессий, медиана - 1
Довольно много выбросов. Есть пользователи, которые за эти 28 дней совершили больше 40 сессий, один из них - больше ста.
#посмотрим количество совершаемых действий
num_of_event = pd.DataFrame(session['event_name'].value_counts()).reset_index()
#переименуем столбцы
num_of_event.columns=['event', 'count']
num_of_event.head(3)
| event | count | |
|---|---|---|
| 0 | tips_show | 40055 |
| 1 | photos_show | 10012 |
| 2 | advert_open | 6164 |
Для удобства анализа все поисковые действия объеденим в одно
#составим датасет, где только поисковые действия
search = num_of_event[num_of_event['event'].str.contains("search")]
#Удалим все строки с search из начального датасета
num_of_event = num_of_event[~num_of_event.event.str.contains("search")]
#добавим строку search в начальный датасет с общей суммой действий
num_of_event.loc[len(num_of_event.event)] = ['search', search['count'].sum()]
#отсортируем
num_of_event = num_of_event.sort_values(by='count', ascending=False)
plt.figure(figsize=(15,5))
plt.bar(num_of_event['event'], num_of_event['count'], color='#668567')
plt.title('Cуммарное количество событий')
plt.xlabel('Действия пользователей')
plt.ylabel('Количество')
plt.xticks(rotation=45)
plt.show()
Чаще всего пользователи видят рекомендованные объявления.
Далее по популярности просмотр фотографий в объявлениях и суммарное количество всех поисковых действий.
Следом открытие карточки объявления, просмотр номера телефона, просмотр карты, добавление в избранное, звонок по номеру
source = sources['source'].value_counts() #датасет с количеством пользователей из каждого источника
plt.figure(figsize=(15,5))
plt.pie(source, autopct='%1.0f%%', colors=('#668567', '#95b88e', '#d9e9a9'), labels=source.index)
plt.title('Количество пользователей по источникам')
plt.show()
Как итог, 45% пользователей установили приложение из Yandex, 26% из Google и 29% из других источников
Перед анализом стоит сделать небольшую оговорку. Неизвестно в какое время пользователь вышел из приложения, поэтому то время, которое пользователь потратил на последнее действие в расчет не пойдет.
Построим сводную таблицу. В рамках каждой сессии найдем самое раннее и самое позднее время (время входа и выхода из приложения), в отдельном столбце посчитаем разницу между ними
asl = session.pivot_table(index='session_id', values='event_time', aggfunc=[min, max]) #сделали сводную таблицу
asl.columns=['time_entrance', 'time_exit'] #переименовали столбцы
asl['duration'] = asl['time_exit'] - asl['time_entrance'] #нашли разницу во времени
asl.sample(3)
| time_entrance | time_exit | duration | |
|---|---|---|---|
| session_id | |||
| 3568 | 2019-10-17 13:34:19.809963 | 2019-10-17 13:34:19.809963 | 0 days 00:00:00 |
| 5589 | 2019-10-19 23:08:08.284510 | 2019-10-19 23:28:39.641573 | 0 days 00:20:31.357063 |
| 3052 | 2019-10-20 13:52:36.495991 | 2019-10-20 13:52:42.645102 | 0 days 00:00:06.149111 |
print(f'Среднее время пребывания в приложении - {asl["duration"].mean()}, медиана - {asl["duration"].median()}')
Среднее время пребывания в приложении - 0 days 00:10:48.680013362, медиана - 0 days 00:04:55.340814
Медиана продолжительности сессии около 5 минут (4:55), в то время как среднее значение - почти 11 (10:49) минут. То есть очень сильный разброс по длительности сессий
asl['duration'] = asl['duration'] / np.timedelta64(1, 'm') #продолжительность сессии переведем в минуты и секунды
plt.figure(figsize=(15,5))
plt.subplot(1,2,1)
asl.boxplot('duration')
plt.title('Продолжительность сессий (полный масштаб)')
plt.ylabel('Продолжительность (минуты)')
plt.subplot(1,2,2)
asl.boxplot('duration')
plt.ylim(0, 20)
plt.title('Продолжительность сессий (приближенный график)')
plt.ylabel('Продолжительность (минуты)')
plt.show()
75% сессий продолжаются до 15 минут, медиана - 5 минут. Есть большое количество аномальных значений, из-за них среднее значение сильно отличается от медианы
advert_open) пользователь за одну сессию¶#оставим в датасете строки только с действием 'advert_open'
adv_open = session.query('event_name =="advert_open"')
#посчитаем cколько действий совершали в рамках каждой сессии
adv_open = adv_open.pivot_table(index='session_id', values='event_name', aggfunc='count')
print(f'В среднем за сессию пользователь успевает посмотреть {round(adv_open["event_name"].mean(),1)} карточек, медиана - {adv_open["event_name"].median()}')
В среднем за сессию пользователь успевает посмотреть 4.8 карточек, медиана - 3.0
plt.figure(figsize=(16,5))
plt.subplot(1,2,1)
adv_open.boxplot('event_name')
plt.title('Количество просмотренных карточек за сессию (полный масштаб)')
plt.ylabel('Количество карточек')
plt.subplot(1,2,2)
adv_open.boxplot('event_name')
plt.ylim(0, 12)
plt.title('Количество просмотренных карточек за сессию (приближенный график)')
plt.ylabel('Количество карточек')
plt.show()
75% пользователей просматривают от 1 до 5 карточек за сессию
DAU (daily аctive users) - количество уникальных пользователей в день
WAU (weekly аctive users) - количество уникальных пользователей в неделю
Посмотрим сколько пользователей заходило каждый день в приложение. Раннее уже посчитали,что всего 4293 пользователя заходило в приложение за 28 дней
#добавим столбцы, в которых выделим дату посещения и неделю соответсвенно
session['date'] = session['event_time'].dt.date
session['week'] = session['event_time'].dt.week
#для каждого дня и для каждой недели посчитаем сколько было уникальных пользователей
dau = session.pivot_table(index='date', values='user_id', aggfunc='nunique').reset_index()
wau = session.pivot_table(index='week', values='user_id', aggfunc='nunique').reset_index()
#выведем статистику по количеству пользователей за день
dau['user_id'].describe()
count 28.000000 mean 279.178571 std 46.737291 min 178.000000 25% 238.250000 50% 292.500000 75% 310.500000 max 352.000000 Name: user_id, dtype: float64
plt.figure(figsize=(15,5))
plt.bar(dau['date'], dau['user_id'], color='#668567')
plt.title('Количество уникальных пользователей в день')
plt.xlabel('Дата')
plt.ylabel('Уникальные пользователи')
plt.show()
В среднем в день заходят 279 человек, медиана - 292. Минимальное количество пользователей за день было 178, максимальное - 352.
После 13 октября количество пользователей в день варьируется около 300.
В первые дни самые низкие показатели, дальше они растут. Интересно исследовать какие внешние или внутренние факторы могли на это повлиять. Возможно, это успешная реклама или интересные акции.
Посчитаем как меняется активность пользователей в выходные дни. Будем считать, что в основной рынок - российский, и выходные приходятся на субботу и воскресенье.
Посчитаем сколько уникальных пользователей заходят каждый день в выходные и будни. Заранее стоит отметить, что данных о будних днях больше, следовательно разброс может быть больше
#в dau переведм поменяем тип столбца с датой
dau['date'] = pd.to_datetime(dau['date'], errors='coerce')
#выделим номер недели
dau['weekday'] = dau['date'].dt.weekday
#добавим столбец. Если день недели равен 5 или 6, то будет значение weekends, в остальных случаях workday
dau['type'] = dau['weekday'].apply ( lambda x: 'weekends' if x in [5,6] else 'workday')
plt.figure(figsize=(15,5))
sns.boxplot(data=dau, x="user_id", y="type", color='#d9e9a9')
plt.title('Количество уникальных пользователей')
plt.xlabel('Количество пользователей')
plt.ylabel('Тип дня')
plt.show()
В рабочие дни больше пользователей заходят в приложение, нежели в выходные. Разница в медиане количества пользователей состаляет около 30 человек
Посчитаем сколько пользователей заходило каждую неделю (WAU)
#выведем статистику по количеству пользователей за день
wau['user_id'].describe()
count 4.000000 mean 1382.500000 std 177.661663 min 1130.000000 25% 1344.500000 50% 1427.000000 75% 1465.000000 max 1546.000000 Name: user_id, dtype: float64
plt.figure(figsize=(15,5))
plt.bar(wau['week'], wau['user_id'], color='#668567')
plt.title('Количество уникальных пользователей в неделю')
plt.xlabel('Номер недели')
plt.ylabel('Уникальные пользователи')
plt.show()
Для анализа диаграммы необходимо убедиться, что нет обрезанных недель (во всех столбцах дни с понедельника по воскресенье).
Посмотрим с какого дня недели предоставлены данные и каким днем они заканчиваются.
#выделим номер недели
session['weekday'] = session['event_time'].dt.weekday
display(session.loc[session['event_time'] == session['event_time'].min()])
display(session.loc[session['event_time'] == session['event_time'].max()])
| event_time | event_name | user_id | session_id | date | week | weekday | |
|---|---|---|---|---|---|---|---|
| 0 | 2019-10-07 00:00:00.431357 | advert_open | 020292ab-89bc-4156-9acf-68bc2783f894 | 79 | 2019-10-07 | 41 | 0 |
| event_time | event_name | user_id | session_id | date | week | weekday | |
|---|---|---|---|---|---|---|---|
| 74196 | 2019-11-03 23:58:12.532487 | tips_show | 28fccdf4-7b9e-42f5-bc73-439a265f20e9 | 1940 | 2019-11-03 | 44 | 6 |
Данные начинаются с понедельника и заканчиваются воскресеньем, значит во всех столбцах информация за полных 7 дней.
Для анализа предоставлены 4 недели - с 41 по 44.
Среднее количество уникальных пользователей в неделю - 1382, медиана - 1427. Минимальное количество за неделю было 1130, максимальное - 1546
Диаграмма чем-то напоминает на предыдущую. С 41 по 43 неделю она идет по возрастающей, на 44 значение немного уменьшается. Но 41 неделя выделяется низкими показателями, лучший показатель на 43.
Посчитаем Sticky Factor (степень липкости), этот показатель характеризует регулярность использования приложения в течении недели или месяца.
Расчет будет примерный, будем брать медианное значение по каждому показателю. За MAU возьмем сколько уникальных пользователей зашло в приложение за 28 дней.
dau = dau['user_id'].median()
wau = wau['user_id'].median()
mau = session['user_id'].nunique()
print ('DAU/WAU = ', "{:.2%}".format(dau/wau))
print ('DAU/MAU = ', "{:.2%}".format(dau/mau))
DAU/WAU = 20.50% DAU/MAU = 6.81%
Для полной картины необходимо отследить данные за бОльший период и посмотреть как меняется это значение.
Результат DAU/MAU довольно низкий - всего 6%, пользователи не задерживаются в приложении
Добавим в датасет столбец time_diff , который в рамках сессии считает сколько времени прошло с предыдущего действия
session['time_diff'] = session.groupby('session_id')['event_time'].diff()
#поднимем значения в time_diff на одну ступень выше
session['time_diff'] = session['time_diff'].shift(-1)
session.head(3)
| event_time | event_name | user_id | session_id | date | week | weekday | time_diff | |
|---|---|---|---|---|---|---|---|---|
| 805 | 2019-10-07 13:39:45.989359 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 1 | 2019-10-07 | 41 | 0 | 0 days 00:00:45.063550 |
| 806 | 2019-10-07 13:40:31.052909 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 1 | 2019-10-07 | 41 | 0 | 0 days 00:00:34.669580 |
| 809 | 2019-10-07 13:41:05.722489 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 1 | 2019-10-07 | 41 | 0 | 0 days 00:02:15.012972 |
Далее будем смотреть по двум пунктам
#выделим отдельным столбцом количество минут
session['time'] = session['time_diff'] / np.timedelta64(1, 's')
#для каждого действия посчитаем сколько времени на нем провели всего
time_event = pd.DataFrame(session.groupby('event_name')['time_diff'].sum()).sort_values(by='time_diff', ascending=False).reset_index()
#для каждого действия посчитаем какая медиана его продолжительности
time_event_median = pd.DataFrame(session.groupby('event_name')['time'].median()).sort_values(by='time', ascending=False).reset_index()
plt.figure(figsize=(15,14))
plt.subplot(2,1,1)
plt.bar(time_event['event_name'], time_event['time_diff'], color='#668567')
plt.xticks(rotation=15)
plt.title('Суммарное количество потраченного времени на каждое действие')
plt.ylabel('Время')
plt.xlabel('Действие')
plt.subplot(2,1,2)
plt.bar(time_event_median['event_name'], time_event_median['time'], color='#d9e9a9')
plt.title('Медиана потраченного времени на каждое действие')
plt.ylabel('Время')
plt.xlabel('Действие')
plt.xticks(rotation=15)
plt.show()
Большую часть своего времени пользователи тратят на просмотр рекомендованных объявлений (это самое часто повторяющееся событие).
Далее идут:
По медианному времени разрыв во времени меньше. События, на которых тратят больше всего времени:
Посмотрим с какого действия клиента начинает свое взаимодействие с приложением и каким заканчивает. Будем выводить датасет полностью, а не только первые строки, так можно сделать больше выводов
print('Действия, которые пользователь совершает первым делом в приложении ')
pd.DataFrame(session.pivot_table(index = 'session_id', values='event_name', aggfunc='first').value_counts())
Действия, которые пользователь совершает первым делом в приложении
| 0 | |
|---|---|
| event_name | |
| tips_show | 4092 |
| photos_show | 1775 |
| search_1 | 1472 |
| map | 1325 |
| show_contacts | 609 |
| search_4 | 529 |
| advert_open | 449 |
| search_7 | 169 |
| search_6 | 143 |
| favorites_add | 119 |
| search_5 | 117 |
| search_3 | 69 |
| tips_click | 56 |
| search_2 | 51 |
Чаще всего клиенты своим первым действием видят рекомендованное объявление.
Также часто первым действием в сессии бывает просмотр фото/поиск/просмотр карты.
print('Действия, которые пользователь совершает перед выходом из приложения')
pd.DataFrame(session.pivot_table(index = 'session_id', values='event_name', aggfunc='last').value_counts())
Действия, которые пользователь совершает перед выходом из приложения
| 0 | |
|---|---|
| event_name | |
| tips_show | 5681 |
| photos_show | 2191 |
| search_1 | 849 |
| show_contacts | 811 |
| map | 351 |
| advert_open | 333 |
| favorites_add | 240 |
| contacts_call | 223 |
| search_5 | 182 |
| tips_click | 55 |
| search_3 | 42 |
| search_4 | 9 |
| search_6 | 4 |
| search_7 | 3 |
| search_2 | 1 |
Чаще всего последнее действие перед выходом - просмотр рекомендованного объявления или просмотр фото объявления.
Пользователи редко выходят из приложения после ряда поисков (2-7).
Стоит принять во внимание, что действия tips_show и photos_show часто встречаются, поэтому они и попали в топ обоих рейтигов.
Выводы по исследовательскому анализу данных:
Для построения диаграммы необходимо посмотреть сренее количество событий в сценарии.
Выделим список сценариев в рамках каждой сессии
def myfunc(column):
""" функция возвращает список уникальных действий
input: действия пользователя
output: список уникальных действий пользователя
"""
return column.unique()
#в рамках сессии выделим уникальные действия
pivot = session.pivot_table(index=['session_id'], values='event_name', aggfunc=myfunc).reset_index()
#если в стобце толкько одно действие, то поменяем тип на list
pivot['event_name'] = pivot['event_name'].agg(lambda x: x if type(x) !=str else x.split())
#уберем сценарии, состоящие из одного шага
pivot = pivot[pivot['event_name'].str.len() > 1]
pivot.sample(5)
| session_id | event_name | |
|---|---|---|
| 10428 | 10429 | [map, tips_show] |
| 5891 | 5892 | [map, tips_show] |
| 7077 | 7078 | [map, show_contacts, tips_show] |
| 1760 | 1761 | [show_contacts, tips_show] |
| 6336 | 6337 | [show_contacts, tips_show] |
Посмотрим сколько действий в сценариях
#добавим столбец с количеством действий в сценарии
pivot['event_count'] = pivot['event_name'].apply( lambda x: len(x))
#запросим информацию по этому столбцу
pivot['event_count'].describe()
count 5337.000000 mean 2.697396 std 0.998715 min 2.000000 25% 2.000000 50% 2.000000 75% 3.000000 max 9.000000 Name: event_count, dtype: float64
Медиана равна 2, среднее - 2.7. Максимальное количество действий в рамках одного сценария - 9. Учтем эту инaормацию при ограничении диаграммы в количестве шагов
Избавимся от повторяющихся событий в рамках сессии
session = session[~session[['event_name', 'session_id']].duplicated()]
session.head()
| event_time | event_name | user_id | session_id | date | week | weekday | time_diff | time | |
|---|---|---|---|---|---|---|---|---|---|
| 805 | 2019-10-07 13:39:45.989359 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 1 | 2019-10-07 | 41 | 0 | 0 days 00:00:45.063550 | 45.063550 |
| 6541 | 2019-10-09 18:33:55.577963 | map | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 2 | 2019-10-09 | 41 | 2 | 0 days 00:01:32.683012 | 92.683012 |
| 6565 | 2019-10-09 18:40:28.738785 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 2 | 2019-10-09 | 41 | 2 | 0 days 00:01:54.225163 | 114.225163 |
| 36412 | 2019-10-21 19:52:30.778932 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 3 | 2019-10-21 | 43 | 0 | 0 days 00:00:46.386077 | 46.386077 |
| 36419 | 2019-10-21 19:53:38.767230 | map | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 3 | 2019-10-21 | 43 | 0 | 0 days 00:01:06.242629 | 66.242629 |
Сделаем копию таблицы session, так как для построения диаграммы придется ее видоизменить
session_1 = session.copy()
Подготовим таблицу
source и target. Там в рамках сессии будет первое действие и последующее соответственноstep будет номер шага этой парыevent_name так как он больше не понадобитсяdef add_features(df):
""" функция возвращает таблицу с новыми столбцами `source`, `targe`, `step` и убирает столбец `event_name`
input: таблица
output: таблица с новыми столбцами (источник, целевое действие, номер шага) и без столбца с названием действия
"""
# сортируем по номеру сессии и времени
sorted_df = df.sort_values(by=['session_id', 'event_time']).copy()
# добавляем шаги событий
sorted_df['step'] = sorted_df.groupby('session_id').cumcount() + 1
# добавляем узлы-источники и целевые узлы
# узлы-источники - это сами события
sorted_df['source'] = sorted_df['event_name']
# добавляем целевые узлы
sorted_df['target'] = sorted_df.groupby('session_id')['source'].shift(-1)
# возврат таблицы без имени событий
return sorted_df.drop(['event_name'], axis=1)
session = add_features(session)
session.head()
| event_time | user_id | session_id | date | week | weekday | time_diff | time | step | source | target | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 805 | 2019-10-07 13:39:45.989359 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 1 | 2019-10-07 | 41 | 0 | 0 days 00:00:45.063550 | 45.063550 | 1 | tips_show | NaN |
| 6541 | 2019-10-09 18:33:55.577963 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 2 | 2019-10-09 | 41 | 2 | 0 days 00:01:32.683012 | 92.683012 | 1 | map | tips_show |
| 6565 | 2019-10-09 18:40:28.738785 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 2 | 2019-10-09 | 41 | 2 | 0 days 00:01:54.225163 | 114.225163 | 2 | tips_show | NaN |
| 36412 | 2019-10-21 19:52:30.778932 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 3 | 2019-10-21 | 43 | 0 | 0 days 00:00:46.386077 | 46.386077 | 1 | tips_show | map |
| 36419 | 2019-10-21 19:53:38.767230 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 3 | 2019-10-21 | 43 | 0 | 0 days 00:01:06.242629 | 66.242629 | 2 | map | NaN |
Необходимо ограничить количество шагов. Выведем предыдущий расчет.
pivot['event_count'].describe()
count 5337.000000 mean 2.697396 std 0.998715 min 2.000000 25% 2.000000 50% 2.000000 75% 3.000000 max 9.000000 Name: event_count, dtype: float64
Медиана по количеству действий в сценарии равна двум, а в 75% сценариев до 3 действий. Поэтому ограничим количество до 3
В df_comp запишем таблицу session, но удалим все пары source-target, шаг которых превышает 3
df_comp = session[session['step'] <= 3].copy().reset_index(drop=True)
Создадим словарь, в котором ключи - это шаги, а значения - словари со списком названий source и соответствующих им индексов.
Затем для каждого шага объединяем имена и индексы в еще один вложенный словарь
def get_source_index(df):
""" функция генерирует индексы столбца `source`
input: таблица
output: словарь с индексами, именами и соответсвиями индексов именам `source`
"""
res_dict = {}
count = 0
# получаем индексы источников
for no, step in enumerate(df['step'].unique().tolist()):
# получаем уникальные наименования для шага
res_dict[no+1] = {}
res_dict[no+1]['sources'] = df[df['step'] == step]['source'].unique().tolist()
res_dict[no+1]['sources_index'] = []
for i in range(len(res_dict[no+1]['sources'])):
res_dict[no+1]['sources_index'].append(count)
count += 1
# соединим списки
for key in res_dict:
res_dict[key]['sources_dict'] = {}
for name, no in zip(res_dict[key]['sources'], res_dict[key]['sources_index']):
res_dict[key]['sources_dict'][name] = no
return res_dict
# создаем словарь
source_indexes = get_source_index(df_comp)
def colors_for_sources(mode='custom'):
""" функция генерирует цвета для диаграммы
input: ничего (запрос на генерацию цветов)
output: словарь с цветами, соответствующими каждому индексу
"""
# словарь, в который сложим цвета в соответствии с индексом
colors_dict = {}
# присваиваем ранее подготовленные цвета
colors = requests.get('https://raw.githubusercontent.com/rusantsovsv/senkey_tutorial/main/json/colors_senkey.json').json()
for no, label in enumerate(df_comp['source'].unique()):
colors_dict[label] = colors['custom_colors'][no]
return colors_dict
# генерируем цвета из списка
colors_dict = colors_for_sources()
Создадим словарь с данными.Для отрисовки нужны следующие данные:
sources - список с индексами source;
targets - список с индексами target;
values - количество уникальных пользователей, совершивших переход между узлами source-target ("объем" потока между узлами);
labels - названия узлов;
colors_labels - цвет узлов;
link_color - цвет потоков между узлами;
link_text - дополнительная информация.
Следующие 2 функции помогут создать словарь этих списков
def percent_users(sources, targets, values):
"""
функция рассчитывает уникальные id в процентах (для вывода в hover text каждого узла)
input: список с индексами `source`, список с индексами `target`, список с "объемами" потоков `values`
output: список с "объемами" потоков в процентах
"""
# объединим источники и метки и найдем пары
zip_lists = list(zip(sources, targets, values))
new_list = []
# подготовим список словарь с общим объемом трафика в узлах
unique_dict = {}
# проходим по каждому узлу
for source, target, value in zip_lists:
if source not in unique_dict:
# находим все источники и считаем общий трафик
unique_dict[source] = 0
for sr, tg, vl in zip_lists:
if sr == source:
unique_dict[source] += vl
# считаем проценты
for source, target, value in zip_lists:
new_list.append(round(100 * value / unique_dict[source], 1))
return new_list
def lists_for_plot(source_indexes=source_indexes, colors=colors_dict, frac=10):
"""
функция создает необходимые переменные списков и возвращает их в виде словаря
input:словарь с именами и индексами source `source_indexes`, словарь с цветами source `colors`,
ограничение на минимальный "объем" между узлами `frac`
output: словарь со списками, необходимыми для диаграммы
"""
sources = []
targets = []
values = []
labels = []
link_color = []
link_text = []
# проходим по каждому шагу
for step in tqdm(sorted(df_comp['step'].unique()), desc='Шаг'):
if step + 1 not in source_indexes:
continue
# получаем индекс источника
temp_dict_source = source_indexes[step]['sources_dict']
# получаем индексы цели
temp_dict_target = source_indexes[step+1]['sources_dict']
# проходим по каждой возможной паре, считаем количество таких пар
for source, index_source in tqdm(temp_dict_source.items()):
for target, index_target in temp_dict_target.items():
# делаем срез данных и считаем количество id
temp_df = df_comp[(df_comp['step'] == step)&(df_comp['source'] == source)&(df_comp['target'] == target)]
value = len(temp_df)
# проверяем минимальный объем потока и добавляем нужные данные
if value > frac:
sources.append(index_source)
targets.append(index_target)
values.append(value)
# делаем поток прозрачным для лучшего отображения
link_color.append(colors[source].replace(', 1)', ', 0.2)'))
labels = []
colors_labels = []
for key in source_indexes:
for name in source_indexes[key]['sources']:
labels.append(name)
colors_labels.append(colors[name])
# посчитаем проценты всех потоков
perc_values = percent_users(sources, targets, values)
# добавим значения процентов для howertext
link_text = []
for perc in perc_values:
link_text.append(f"{perc}%")
# возвратим словарь с вложенными списками
return {'sources': sources,
'targets': targets,
'values': values,
'labels': labels,
'colors_labels': colors_labels,
'link_color': link_color,
'link_text': link_text}
# создаем словарь
data_for_plot = lists_for_plot()
Шаг: 0%| | 0/3 [00:00<?, ?it/s]
0%| | 0/14 [00:00<?, ?it/s]
0%| | 0/15 [00:00<?, ?it/s]
def plot_senkey_diagram(data_dict=data_for_plot):
"""
функция строит диаграмму Сенкей
input: словарь со списками данных для построения
output: диаграммы Sankey
"""
fig = go.Figure(data=[go.Sankey(
domain = dict(
x = [0,1],
y = [0,1]
),
orientation = "h",
valueformat = ".0f",
node = dict(
pad = 50,
thickness = 15,
line = dict(color = "black", width = 0.1),
label = data_dict['labels'],
color = data_dict['colors_labels']
),
link = dict(
source = data_dict['sources'],
target = data_dict['targets'],
value = data_dict['values'],
label = data_dict['link_text'],
color = data_dict['link_color']
))])
fig.update_layout(title_text="Sankey Diagram", font_size=10, width=800, height=600)
# возвращаем объект диаграммы
return fig
# сохраняем диаграмму в переменную
senkey_diagram = plot_senkey_diagram()
senkey_diagram.show()
Хоть мы и ограничили сценарии до трех шагов, диаграмма все равно получилась большая. По диаграмме можно сделать множество выводов, но вот некоторые из них:
По второму source:
Выводы по целевому действию - просмотр контактов
вторым шагом к просмотру контактов приходят со следующих шагов
Чаще всего к просмотру контактов приходят через самые популярные действия (просмотр рекомендаций и фото, поиск первого типа)
Целевым действием посчтаем просмотр контактов show_contacts, так как именно это действие показывает заинтересованность пользователя объявлением
pivot.sample(3)
| session_id | event_name | event_count | |
|---|---|---|---|
| 2196 | 2197 | [search_4, search_5, tips_show] | 3 |
| 5588 | 5589 | [search_1, photos_show] | 2 |
| 7713 | 7714 | [search_5, tips_show] | 2 |
#раскроем список действий
pivot['event_name'] = pivot['event_name'].agg(lambda x: ', '.join(x))
pivot.head(5)
| session_id | event_name | event_count | |
|---|---|---|---|
| 1 | 2 | map, tips_show | 2 |
| 2 | 3 | tips_show, map | 2 |
| 3 | 4 | map, tips_show | 2 |
| 4 | 5 | search_1, photos_show | 2 |
| 5 | 6 | search_1, photos_show, favorites_add, show_con... | 5 |
#раскроем список действий
pivot['event_name'] = pivot['event_name'].agg(lambda x: ', '.join(x) if type(x) !=str else x )
pivot.head(5)
| session_id | event_name | event_count | |
|---|---|---|---|
| 1 | 2 | map, tips_show | 2 |
| 2 | 3 | tips_show, map | 2 |
| 3 | 4 | map, tips_show | 2 |
| 4 | 5 | search_1, photos_show | 2 |
| 5 | 6 | search_1, photos_show, favorites_add, show_con... | 5 |
Найдем самые популярные сценарии, которые приводят к целевому действию
#gосчитаем сколько раз повторяется каждый сценарий
scenario = pd.DataFrame(pivot.groupby('event_name')['session_id'].count()).reset_index()
#уберем сценарии, в которых нет целевого действия
scenario = scenario.loc[scenario['event_name'].str.contains('show_contacts')]
#уберем сценарии, которые начинаются с целевого дейтсвия (так как задача посмотреть какие действия приводят к целевому действию)
scenario = scenario.loc[~scenario['event_name'].str.startswith('show_contacts')]
#остортируем по убыванию популярности сценариев
scenario = scenario.sort_values(by='session_id', ascending=False).reset_index()
scenario.head(5)
| index | event_name | session_id | |
|---|---|---|---|
| 0 | 639 | tips_show, show_contacts | 376 |
| 1 | 151 | map, tips_show, show_contacts | 93 |
| 2 | 180 | photos_show, show_contacts | 83 |
| 3 | 217 | search_1, show_contacts, contacts_call | 52 |
| 4 | 216 | search_1, show_contacts | 44 |
Пользователи проходят разные шаги перед открытием номера телефона, но вот самые популярные сценарии:
Продублируем четыре самых популярных сценария
scenario.head(4)
| index | event_name | session_id | |
|---|---|---|---|
| 0 | 639 | tips_show, show_contacts | 376 |
| 1 | 151 | map, tips_show, show_contacts | 93 |
| 2 | 180 | photos_show, show_contacts | 83 |
| 3 | 217 | search_1, show_contacts, contacts_call | 52 |
Визуализируем сценарии в виде воронок
for i in range(4):
scenario_list = scenario.loc[i, 'event_name'].split(', ') #преобразовывает колонку `event_name` в список действий
print(f'Воронка по сценарию {" - ".join(scenario_list)}. По такому пути пользователи прошли {scenario["session_id"][i]} раз')
sp=[] #подготовим пустой список
df = session_1.copy() #запишем в df полную таблицу
#для каждого действия в сценарии
for e in scenario_list:
# в `users` запишем id пользователей, кто совершал это действие
users = df.query('event_name == @e')['user_id']
# `df` перезапишем и оставим там только тех пользователей, чьи id в переменной `users`
df = df.query('user_id in @users')
# количество оставшихся уникальных пользователей запиешем в список
sp.append(df['user_id'].nunique())
#построим воронку
fig = go.Figure(go.Funnel(
y = scenario_list,
x = sp,
textposition = "inside",
textinfo = "value+percent initial",
marker = {"color":'forestgreen'}))
fig.show()
Воронка по сценарию tips_show - show_contacts. По такому пути пользователи прошли 376 раз
Воронка по сценарию map - tips_show - show_contacts. По такому пути пользователи прошли 93 раз
Воронка по сценарию photos_show - show_contacts. По такому пути пользователи прошли 83 раз
Воронка по сценарию search_1 - show_contacts - contacts_call. По такому пути пользователи прошли 52 раз
Воронки получились небольшие - на 2-3 шага. Посмотрим на каждую из них.
Увидел рекомендованные объявления - посмотрел номер телефона.
Открыл карту объявлений - увидел рекомендованные объявления - посмотрел номер телефона
Выводы, основанные на анализе 4 воронок:
Будем исследовать время между search_1 (самое часто повторяющееся действие поиска) и advert_open
Разделим пользователей на две группы
users_sc - группа с пользователями, которые посмотрели контакты
users_without_sc- группа с пользователями, которые не посмотрели контакты
# создадим список пользователей, которые просмотрели контакты
show_contacts = session_1.query('event_name == "show_contacts"')['user_id'].unique()
#оставим пользователей, чьи id есть в `show_contacts`
users_sc = session_1.query('user_id in @show_contacts ')
#оставим пользователей, чьих id нет в `show_contacts`
users_without_sc = session_1.query('user_id not in @show_contacts')
Найдем медианное значение времени между поиском и открытием у первой группы (тех, кто просмотрел контакты)
mean, median = [],[]
for e in [users_sc, users_without_sc]:
#список сессий, в которых есть поиск
search = e.query('event_name == "search_1"')['session_id']
#список сессий, в которых есть открытие
adv_open = e.query('event_name == "advert_open"')['session_id']
#cписок сессий, в рамках которых есть и поиск и открытие объявления
session_list = list(set(search)&set(adv_open))
# оставим строки только с сессиями из `session_sc`
df = e.query('session_id in @session_list')
#оставим только два действия (поиск и открытие)
df = df.query('event_name in ["search_1", "advert_open"]')
#найдем разницу между действиями в рамках одной сессии
df['time'] = df.groupby('session_id')['event_time'].diff()
#посчитаем медианное значение времени
df_mean = df['time'].mean()
df_median = df['time'].median()
mean.append(df_mean)
median.append(df_median)
print('Среднее время поиска', mean)
print('Медиана времени поиска', median)
Среднее время поиска [Timedelta('0 days 00:10:16.597706166'), Timedelta('0 days 00:13:52.897521047')]
Медиана времени поиска [Timedelta('0 days 00:07:27.938930'), Timedelta('0 days 00:10:10.340973')]
У первой группы (пользователей, просмотревших контакты) среднее время поиска составляет 10 минут 17 секунд, медиана - 13 минут 52 секунды.
У второй группы (пользователей, не просмотревших контакты) среднее время поиска составляет 7 минут 28 секунд, медиана - 10 минут 10 секунд.
print(f'Разница медиан длительности времени между поиском и открытием объявления составляет {median[1] - median[0]}')
Разница медиан длительности времени между поиском и открытием объявления составляет 0 days 00:02:42.402043
Пользователи, которые смотрят контакты тратят меньше времени на поиск. Как правило, на поиск перед открытием объявления они тратят на 2.42 минуты меньше чем те, кто не смотрит контакты.
Такую закономерность можно поробовать обосновать. Возможно, что долгий поиск обуславливается отсутсвием нужных предложений, как следствие отсутсвие сильного интереса к найденному объявлению
Выводы по разделу:
Выдвинем и проверим три гипотезы
tips_show и tips_click, другие — только tips_show. Гипотеза: конверсия в просмотры контактов различается у этих двух групп.favorites_add иная, чем у пользователей, не совершивших это действиеНапишем функцию для работы с гипотезами. Для всех гипотез будем использовать z-test (о равенстве долей)
def hypothesis_testing(trials, successes):
"""
функция выводит значение p-value и выводы о принятии/отвержении гипотезы
input: список с общим количеством пользователей в каждой из групп,
список с количеством пользователей, совершивших целевое действие в каждой из групп,
output: значение p-value и выводы о принятии/отвержении гипотезы
"""
# зададим критический уровень статистической значимости
alpha = .05
# пропорция успехов в первой группе:
p1 = successes[0]/trials[0]
# пропорция успехов во второй группе:
p2 = successes[1]/trials[1]
# пропорция успехов в комбинированном датасете:
p_combined = (successes[0] + successes[1]) / (trials[0] + trials[1])
# разница пропорций в датасетах
difference = p1 - p2
# считаем статистику в ст.отклонениях стандартного нормального распределения
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/trials[0] + 1/trials[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
#выводим вывод
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между долями есть значимая разница')
else:
print('Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными')
Если клиенту попадается рекомендованное объявление, и он переходит по нему, значит объявление было верно подобрано и пользователь заинтересован. Интересено узнать, в таком случае будет ли конверсия в просмотр контакты выше по сравнению с тем пользователями, которые не переходят по рекомедованному оъявлению
Первая группа пользователей совершают действия tips_show и tips_click (увидел рекомендованные объявления и кликнул по нему)
Вторая группа пользователей только tips_show (увидел рекомендованное объявление, но не кликнул по нему)
Нулевая гипотеза: конверсия в просмотры контактов не различается у этих двух групп
Альтернативная гипотеза: между конверсией в просмотры контактов у этих двух групп есть статистически значимая разница
Создадим сводную таблицу и для каждого пользователя выведем список его уникальных действий, сохраним в перменной event_for_users
event_for_users = session_1.groupby('user_id')['event_name'].unique().reset_index()
event_for_users['event_name'] = event_for_users['event_name'].agg(lambda x: ' '.join(x))
event_for_users.head(3)
| user_id | event_name | |
|---|---|---|
| 0 | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | tips_show map |
| 1 | 00157779-810c-4498-9e05-a1e9e3cedf93 | search_1 photos_show favorites_add show_contac... |
| 2 | 00463033-5717-4bf1-91b4-09183923b9df | photos_show |
Для каждой из групп найдем:
#в `group_1` сохраним id только тех пользователей, кто совершил `tips_show` и `tips_click`
group_1 = event_for_users.loc[event_for_users['event_name'].str.contains("tips_show") & event_for_users['event_name'].str.contains("tips_click")]
#посчитаем количество уникальных пользователей
gr_1 = group_1['user_id'].nunique()
print(gr_1, 'пользователей в первой группе')
297 пользователей в первой группе
#посчитаем количество уникальных пользователей из группы 1, которые посмотрели контакты
gr_1_sc = group_1.loc[group_1['event_name'].str.contains("show_contacts")]['user_id'].nunique()
print(gr_1_sc, 'пользователей из первой группы посмотрели контакты' )
91 пользователей из первой группы посмотрели контакты
#в `group_2` сохраним id только тех пользователей, кто совершил `tips_show`, но не совершил `tips_click`
group_2 = event_for_users.loc[event_for_users['event_name'].str.contains("tips_show") & ~(event_for_users['event_name'].str.contains("tips_click"))]
#посчитаем количество уникальных пользователей
gr_2 = group_2['user_id'].nunique()
print(gr_2, 'пользователей во второй группе')
2504 пользователей во второй группе
#посчитаем количество уникальных пользователей из группы 2, которые посмотрели контакты
gr_2_sc = group_2.loc[group_2['event_name'].str.contains("show_contacts")]['user_id'].nunique()
print(gr_2_sc, 'пользователей из второй группы посмотрели контакты')
425 пользователей из второй группы посмотрели контакты
print(f'''
{gr_1} пользователей увидели рекомендованное объявление и перешли по нему.
Из них {gr_1_sc} посмотрели номер телефона.
{gr_2} пользователь увидели рекомендованное объявление, но не перешли по нему.
Из них {gr_2_sc} посмотрели номер телефона.
''')
297 пользователей увидели рекомендованное объявление и перешли по нему. Из них 91 посмотрели номер телефона. 2504 пользователь увидели рекомендованное объявление, но не перешли по нему. Из них 425 посмотрели номер телефона.
#передадим полученные значения функции
successes = [gr_1_sc, gr_2_sc]
trials = [gr_1, gr_2]
hypothesis_testing(trials, successes)
p-значение: 9.218316554537864e-09 Отвергаем нулевую гипотезу: между долями есть значимая разница
Между долями есть значимая разница. Как ни странно, но у пользователей, которые не перходили по рекомендованному объявлению конверсия в просмотр контактов выше.
Проверим, выше ли конверсия в просмотр контактов у пользователей, которые добавляют объявления в избранное (действие favorites_add)
Нулевая гипотеза: конверсия в просмотр контактов не различается у пользователей, добавляющих объявления в избранное и не делающих это
Альтернативная гипотеза: между конверсией в просмотр контактов у пользователей, добавляющих объявления в избранное и не делающих это есть статистически значимая разница
Для каждой из групп найдем:
# создадим список пользователей кто добавлял объявления в избранное
fa = session_1.query('event_name == "favorites_add"')['user_id'] # список пользователей кто добавлял объявления в избранное
# создадим список пользователей кто не добавлял объявления в избранное
nfa = session_1.query('user_id not in @fa')['user_id']
#посчитаем количество уникальных пользователей в каждой из групп
fa_count = fa.nunique()
nfa_count = nfa.nunique()
print(f'{fa_count} пользователей добавляли объявления в избранное, а {nfa_count} не совершали это действие')
351 пользователей добавляли объявления в избранное, а 3942 не совершали это действие
#датасет с событием "посмотрел контакт"
show_cont = session_1.query('event_name == "show_contacts"')
#для каждой группы посчитаем сколько пользователей посмотрели контакты
sc_fa = show_cont.query('user_id in @fa')['user_id'].nunique()
sc_nfa = show_cont.query('user_id in @nfa')['user_id'].nunique()
print(f'Из первой группы посмотрели контакты {sc_fa} пользователей, из второй - {sc_nfa}')
Из первой группы посмотрели контакты 136 пользователей, из второй - 845
#передадим полученные значения функции
successes = [sc_fa, sc_nfa]
trials = [fa_count, nfa_count]
hypothesis_testing(trials, successes)
p-значение: 1.3455903058456897e-13 Отвергаем нулевую гипотезу: между долями есть значимая разница
Между конверсией в просмотр контактов у пользователей, добавляющих объявления в избранное и не делающих это есть статистически значимая разница.
Пользователи, которые добавляют объявление в избранное чаще просматривают номера телефонов.
Для продвижения сервиса необходимо выяснить, есть ли разница между пользователями и их поведением в зависимости от источника, с которого они установили приложения
Нулевая гипотеза: конверсия в просмотры контактов не различается у пользователей, установивших приложения с Yandex и установивших с Google
Альтернативная гипотеза: между конверсией в просмотры контактов у пользователей, установивших приложения с Yandex и установивших с Google есть статистически значимая разница
Для каждой из групп найдем:
#объеденим два датафрейма
common = session_1.merge(sources, on='user_id')
common.head(3)
| event_time | event_name | user_id | session_id | date | week | weekday | time_diff | time | source | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 2019-10-07 13:39:45.989359 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 1 | 2019-10-07 | 41 | 0 | 0 days 00:00:45.063550 | 45.063550 | other |
| 1 | 2019-10-09 18:33:55.577963 | map | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 2 | 2019-10-09 | 41 | 2 | 0 days 00:01:32.683012 | 92.683012 | other |
| 2 | 2019-10-09 18:40:28.738785 | tips_show | 0001b1d5-b74a-4cbf-aeb0-7df5947bf349 | 2 | 2019-10-09 | 41 | 2 | 0 days 00:01:54.225163 | 114.225163 | other |
#для каждого источника посчитаем количество уникальных пользователей
source = common.pivot_table(index='source', values='user_id', aggfunc='nunique')
display(source)
#для каждый группы запишем и выведем значение
yandex = source.loc['yandex', 'user_id']
google = source.loc['google', 'user_id']
print(f'''
Из Yandex установило {yandex} пользователей
Из Google - {google}''')
| user_id | |
|---|---|
| source | |
| 1129 | |
| other | 1230 |
| yandex | 1934 |
Из Yandex установило 1934 пользователей Из Google - 1129
#оставим только строки с действием `show_contacts`
common = common.loc[common['event_name'] == "show_contacts"]
#подготовим пустой список
successes = []
for e in ['yandex', 'google']:
#оставим в таблице только пользователей из источника
df = common.query('source == @e')
#посчитаем количество уникальных пользователей, посмотревших контакты
successes.append(df['user_id'].nunique())
#оставим только строки с действием `show_contacts`
common = common.loc[common['event_name'] == "show_contacts"]
#подготовим пустой список
successes = []
for e in ['yandex', 'google']:
#в список добавим пользователей из данного источника, посчитаем их колиество
successes.append(common.query('source == @e')['user_id'].nunique())
print(f'Контакты посмотрели {successes[0]} пользователей из Yandex и {successes[1]} из Google')
Контакты посмотрели 478 пользователей из Yandex и 275 из Google
#передадим полученные значения функции
successes = successes
trials = [yandex, google]
hypothesis_testing(trials, successes)
p-значение: 0.8244316027993777 Не получилось отвергнуть нулевую гипотезу, нет оснований считать доли разными
Конверсия в просмотры контактов не различается у пользователей, установивших приложения с Yandex и установивших с Google
Выводы по гипотезам
У пользователей, которые не перходили по рекомендованному объявлению конверсия в просмотр контактов выше. Выборка не очень большая и сложно делать выводы, основываясь только на этих данных.
Рекомендация: провести аналогичное исследование, используя данные за бОльший период (например, пол года)
Пользователи, которые добавляют объявление в избранное чаще просматривают номера телефонов. Выборка не очень большая, но все равно показательная. Действительно, добавление объявления в избранное показывает заинтересованность пользователя.
Поведение пользователей, установивших приложение с этих двух разных источников идентично, по крайней мере по конверсии просмотра контактов.
Было проанализировано поведение 4293 уникальных пользователей за период с 9 октября по 5 ноября 2019 года
По источникам установки приложения статистика такая: 45% пользователей из Yandex, 26% из Google и 29% из других источников
Для анализа мы разделили действия пользователей на сессии, временной ориентир - между действиями прошло не больше 20 минут:
Активность пользователей:
Действия пользователей:
Исследование основных вопросов:
Выводы по гипотезам:
Были предоставлены данные за небольшой промежуток времени, тяжело сделать по ним какие-либо выводы, необходимо предоставить данные за бОльший промежуток времени с целью анализа динамики и сравнения показателей.
Пользователи не заинтересованы, необходимо улучшить пользовательский опыт, удержать его в приложении
Пользователь проводит очень мало времени в приложении, совершает небольшое количество действий. Значит необходимо сделать это небольшое пребывание в приложении максимально комфортным, пользователь не будет долго разбираться в интерфейсе. Необходимо максимально укоротить путь клиента от входа в приложение до просмотра контакта. В этом может помочь грамотная рекомендация объявлений.
Действие "увидел рекомендованные объявления" самое популярное. Пользователи чаще всего начинают и заканчивают свое взаимодействие этим действием, после него же чаще всего пользователи смотрят контакты. Подбирать и показывать объявления пользователем - отличный инструмент, если им правильно воспользоваться. В данной ситуации очень важно чтобы объявления соответствовали запросам клиента.
Время между поиском и открытием объявления у пользователей, которые в итоге посмотрели контакты в объявлении меньше примерно на 2.40 минут по сравнению с теми пользователями, кто не посмотрел контакты. То есть посмотрел контакты тот, кто нашел объявление быстрее. Если поиск удобен и грамотно предлагает пользователю необходимую информацию, то скорее всего выданное объявление заинтересует пользователя, и он в итоге посмотрит контакты. Необходимо улучшать поисковую систему чтобы пользователь мог комфортнее и быстрее находить нужную информацию/нужные объявления
Гипотеза подтвердилась - пользователи, которые добавляют объявление в избранное чаще просматривают номера телефонов. Действие "добавил в избранное" показывает заинтересованность, а значит и скорее всего пользователь посмотрит номер контакта. Возможно стоит обратить внимание на эту кнопку - насколько удобно она расположена, видно ли ее, как выглядит место, куда сохраняются объявления. Можно рассмотреть вариант предлагать пользователю сохранить объявление, если он на протяжении 20 секунд смотрит фотографии или карту в этом объявлении.
По результатам проверки гипотезы у пользователей, которые видели рекомендованные объявления, но не переходили по нему конверсия в просмотр контактов выше, чем у тех, кто увидел и перешел. Можно предположить, что дело в некачественно подобранных рекомендованных объявлениях, которые были предложены пользователю.